from __future__ import annotations

import threading
import time
from typing import Callable, Optional, List, Any

try:
    import serial  # type: ignore
    from serial.tools import list_ports  # type: ignore
except Exception as exc:  # pragma: no cover - handled at runtime
    serial = None  # type: ignore
    list_ports = None  # type: ignore


class SerialClient:
    """
    Minimal serial client wrapper with background reader thread.

    on_data: callback receiving decoded str; called from reader thread.
    """

    def __init__(self, encoding: str = "utf-8", on_data: Optional[Callable[[str], None]] = None) -> None:
        self._encoding = encoding
        self._on_data = on_data
        # Use plain assignment to avoid typing issues in limited parsers
        self._ser = None  # type: Optional[Any]
        self._running = False
        self._t = None  # type: Optional[threading.Thread]

    @staticmethod
    def list_available_ports() -> List[str]:
        if list_ports is None:
            return []
        return [p.device for p in list_ports.comports()]

    @property
    def is_connected(self) -> bool:
        return bool(self._ser and self._ser.is_open)

    def connect(self, port: str, baudrate: int = 9600, timeout: float = 0.1) -> None:
        if serial is None:
            raise RuntimeError("pyserial not installed. Please install 'pyserial'.")
        self.disconnect()
        self._ser = serial.Serial(port=port, baudrate=baudrate, timeout=timeout)
        self._running = True
        self._t = threading.Thread(target=self._reader, name="SerialReader", daemon=True)
        self._t.start()

    def disconnect(self) -> None:
        self._running = False
        if self._t and self._t.is_alive():
            self._t.join(timeout=1.0)
        self._t = None
        if self._ser:
            try:
                self._ser.close()
            except Exception:
                pass
            self._ser = None

    def send_raw(self, data: bytes) -> None:
        if not self._ser or not self._ser.is_open:
            raise RuntimeError("Serial not connected")
        self._ser.write(data)

    def send_line(self, text: str, line_end: str = "\n") -> None:
        payload = (text + line_end).encode(self._encoding, errors="replace")
        self.send_raw(payload)

    # Internal
    def _reader(self) -> None:
        assert self._ser is not None
        while self._running and self._ser and self._ser.is_open:
            try:
                chunk = self._ser.read(1024)
                if chunk:
                    # Normalize some 8-bit ANSI C1 control codes to equivalent ESC-prefixed forms
                    # This helps later ANSI filtering and avoids UTF-8 decode replacement characters
                    try:
                        # CSI (0x9B) -> ESC[
                        chunk = chunk.replace(b"\x9b", b"\x1b[")
                        # OSC (0x9D) -> ESC]
                        chunk = chunk.replace(b"\x9d", b"\x1b]")
                        # ST (0x9C) -> ESC\
                        chunk = chunk.replace(b"\x9c", b"\x1b\\")
                        # DCS (0x90) -> ESCP
                        chunk = chunk.replace(b"\x90", b"\x1bP")
                        # PM (0x9E) -> ESC^
                        chunk = chunk.replace(b"\x9e", b"\x1b^")
                        # APC (0x9F) -> ESC_
                        chunk = chunk.replace(b"\x9f", b"\x1b_")
                    except Exception:
                        pass
                    try:
                        s = chunk.decode(self._encoding, errors="replace")
                    except Exception:
                        s = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
                    if self._on_data:
                        self._on_data(s)
                else:
                    time.sleep(0.02)
            except Exception:
                # Stop on hard error
                break
        self._running = False
